aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/websites/[websiteId]/(reports)/journeys
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-24 13:09:50 +0000
committerFuwn <[email protected]>2026-01-24 13:09:50 +0000
commit396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch)
treeb9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/websites/[websiteId]/(reports)/journeys
downloadumami-main.tar.xz
umami-main.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/(reports)/journeys')
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css267
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx294
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx67
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx12
4 files changed, 640 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css
new file mode 100644
index 0000000..63643f1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css
@@ -0,0 +1,267 @@
+.container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+
+ --journey-line-color: var(--base-color-6);
+ --journey-active-color: var(--primary-color);
+ --journey-faded-color: var(--base-color-3);
+}
+
+.view {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow: auto;
+ gap: 100px;
+ padding-right: 20px;
+}
+
+.header {
+ margin-bottom: 20px;
+}
+
+.stats {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+ width: 100%;
+}
+
+.visitors {
+ font-weight: 600;
+ font-size: 16px;
+ text-transform: lowercase;
+}
+
+.dropoff {
+ font-weight: 600;
+ color: var(--font-color-muted);
+ background: var(--base-color-2);
+ padding: 4px 8px;
+ border-radius: 5px;
+}
+
+.num {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100%;
+ width: 50px;
+ height: 50px;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+ z-index: 1;
+ margin: 0 auto 20px;
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+}
+
+.nodes {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.wrapper {
+ padding-bottom: 10px;
+}
+
+.node {
+ position: relative;
+ cursor: pointer;
+ padding: 10px 20px;
+ background: var(--base-color-3);
+ border-radius: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 300px;
+ max-width: 300px;
+ height: 60px;
+ max-height: 60px;
+}
+
+.node:hover:not(.selected) {
+ background: var(--base-color-4);
+}
+
+.node.selected {
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+}
+
+.node.active {
+ color: var(--primary-font-color);
+ background: var(--primary-color);
+}
+
+.node.selected .count {
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+}
+
+.node.selected.active .count {
+ color: var(--primary-font-color);
+ background: var(--primary-color);
+}
+
+.name {
+ max-width: 200px;
+}
+
+.line {
+ position: absolute;
+ bottom: 0;
+ left: -100px;
+ width: 100px;
+ pointer-events: none;
+}
+
+.line.up {
+ bottom: 0;
+}
+
+.line.down {
+ top: 0;
+}
+
+.segment {
+ position: absolute;
+}
+
+.start {
+ left: 0;
+ width: 50px;
+ height: 30px;
+ border: 0;
+}
+
+.mid {
+ top: 60px;
+ width: 50px;
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.end {
+ width: 50px;
+ height: 30px;
+ border: 0;
+}
+
+.up .start {
+ top: 30px;
+ border-top-right-radius: 100%;
+ border-top: 3px solid var(--journey-line-color);
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.up .end {
+ width: 52px;
+ bottom: 27px;
+ right: 0;
+ border-bottom-left-radius: 100%;
+ border-bottom: 3px solid var(--journey-line-color);
+ border-left: 3px solid var(--journey-line-color);
+}
+
+.down .start {
+ bottom: 27px;
+ border-bottom-right-radius: 100%;
+ border-bottom: 3px solid var(--journey-line-color);
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.down .end {
+ width: 52px;
+ top: 30px;
+ right: 0;
+ border-top-left-radius: 100%;
+ border-top: 3px solid var(--journey-line-color);
+ border-left: 3px solid var(--journey-line-color);
+}
+
+.flat .start {
+ left: 0;
+ top: 30px;
+ border-top: 3px solid var(--journey-line-color);
+}
+
+.flat .end {
+ right: 0;
+ top: 30px;
+ border-top: 3px solid var(--journey-line-color);
+}
+
+.start:before,
+.end:before {
+ content: "";
+ position: absolute;
+ border-radius: 100%;
+ border: 3px solid var(--journey-line-color);
+ background: var(--base-color-1);
+ width: 14px;
+ height: 14px;
+}
+
+.line:not(.active) .start:before,
+.line:not(.active) .end:before {
+ display: none;
+}
+
+.up .start:before {
+ left: -8px;
+ top: -8px;
+}
+
+.up .end:before {
+ right: -8px;
+ bottom: -8px;
+}
+
+.down .start:before {
+ left: -8px;
+ bottom: -8px;
+}
+
+.down .end:before {
+ right: -8px;
+ top: -8px;
+}
+
+.flat .start:before {
+ left: -8px;
+ top: -8px;
+}
+
+.flat .end:before {
+ right: -8px;
+ top: -8px;
+}
+
+.line.active .segment,
+.line.active .segment:before {
+ border-color: var(--journey-active-color);
+ z-index: 1;
+}
+
+.column.active .line:not(.active) .segment {
+ border-color: var(--journey-faded-color);
+}
+
+.column.active .line:not(.active) .segment:before {
+ display: none;
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
new file mode 100644
index 0000000..3327a42
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
@@ -0,0 +1,294 @@
+import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import classNames from 'classnames';
+import { useMemo, useState } from 'react';
+import { firstBy } from 'thenby';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
+import { File } from '@/components/icons';
+import { Lightning } from '@/components/svg';
+import { objectToArray } from '@/lib/data';
+import { formatLongNumber } from '@/lib/format';
+import styles from './Journey.module.css';
+
+const NODE_HEIGHT = 60;
+const NODE_GAP = 10;
+const LINE_WIDTH = 3;
+
+export interface JourneyProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ steps: number;
+ startStep?: string;
+ endStep?: string;
+}
+
+export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) {
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [activeNode, setActiveNode] = useState(null);
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery<any>('journey', {
+ websiteId,
+ steps,
+ startStep,
+ endStep,
+ });
+
+ useEscapeKey(() => setSelectedNode(null));
+
+ const columns = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ const selectedPaths = selectedNode?.paths ?? [];
+ const activePaths = activeNode?.paths ?? [];
+ const columns = [];
+
+ for (let columnIndex = 0; columnIndex < +steps; columnIndex++) {
+ const nodes = {};
+
+ data.forEach(({ items, count }: any, nodeIndex: any) => {
+ const name = items[columnIndex];
+
+ if (name) {
+ const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
+ const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
+
+ if (!nodes[name]) {
+ const paths = data.filter(({ items }) => items[columnIndex] === name);
+
+ nodes[name] = {
+ name,
+ count,
+ totalCount: count,
+ nodeIndex,
+ columnIndex,
+ selected,
+ active,
+ paths,
+ pathMap: paths.map(({ items, count }) => ({
+ [`${columnIndex}:${items.join(':')}`]: count,
+ })),
+ };
+ } else {
+ nodes[name].totalCount += count;
+ }
+ }
+ });
+
+ columns.push({
+ nodes: objectToArray(nodes).sort(firstBy('total', -1)),
+ });
+ }
+
+ columns.forEach((column, columnIndex) => {
+ const nodes = column.nodes.map(
+ (
+ currentNode: { totalCount: number; name: string; selected: boolean },
+ currentNodeIndex: any,
+ ) => {
+ const previousNodes = columns[columnIndex - 1]?.nodes;
+ let selectedCount = previousNodes ? 0 : currentNode.totalCount;
+ let activeCount = selectedCount;
+
+ const lines =
+ previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
+ const fromCount = selectedNode?.paths.reduce((sum, path) => {
+ if (
+ previousNode.name === path.items[columnIndex - 1] &&
+ currentNode.name === path.items[columnIndex]
+ ) {
+ sum += path.count;
+ }
+ return sum;
+ }, 0);
+
+ if (currentNode.selected && previousNode.selected && fromCount) {
+ arr.push([previousNodeIndex, currentNodeIndex]);
+ selectedCount += fromCount;
+
+ if (previousNode.active) {
+ activeCount += fromCount;
+ }
+ }
+
+ return arr;
+ }, []) || [];
+
+ return { ...currentNode, selectedCount, activeCount, lines };
+ },
+ );
+
+ const visitorCount = nodes.reduce(
+ (sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
+ if (!selectedNode) {
+ sum += totalCount;
+ } else if (!activeNode && selectedNode && selected) {
+ sum += selectedCount;
+ } else if (activeNode && active) {
+ sum += activeCount;
+ }
+ return sum;
+ },
+ 0,
+ );
+
+ const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
+ const dropOff =
+ previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
+
+ Object.assign(column, { nodes, visitorCount, dropOff });
+ });
+
+ return columns;
+ }, [data, selectedNode, activeNode]);
+
+ const handleClick = (name: string, columnIndex: number, paths: any[]) => {
+ if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
+ setSelectedNode({ name, columnIndex, paths });
+ } else {
+ setSelectedNode(null);
+ }
+ setActiveNode(null);
+ };
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error} height="100%">
+ <div className={styles.container}>
+ <div className={styles.view}>
+ {columns.map(({ visitorCount, nodes }, columnIndex) => {
+ return (
+ <div
+ key={columnIndex}
+ className={classNames(styles.column, {
+ [styles.selected]: selectedNode,
+ [styles.active]: activeNode,
+ })}
+ >
+ <div className={styles.header}>
+ <div className={styles.num}>{columnIndex + 1}</div>
+ <div className={styles.stats}>
+ <div className={styles.visitors} title={visitorCount}>
+ {formatLongNumber(visitorCount)} {formatMessage(labels.visitors)}
+ </div>
+ </div>
+ </div>
+ <div className={styles.nodes}>
+ {nodes.map(
+ ({
+ name,
+ totalCount,
+ selected,
+ active,
+ paths,
+ activeCount,
+ selectedCount,
+ lines,
+ }) => {
+ const nodeCount = selected
+ ? active
+ ? activeCount
+ : selectedCount
+ : totalCount;
+
+ const remaining =
+ columnIndex > 0
+ ? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100)
+ : 0;
+
+ const dropped = 100 - remaining;
+
+ return (
+ <div
+ key={name}
+ className={styles.wrapper}
+ onMouseEnter={() =>
+ selected && setActiveNode({ name, columnIndex, paths })
+ }
+ onMouseLeave={() => selected && setActiveNode(null)}
+ >
+ <div
+ className={classNames(styles.node, {
+ [styles.selected]: selected,
+ [styles.active]: active,
+ })}
+ onClick={() => handleClick(name, columnIndex, paths)}
+ >
+ <Row alignItems="center" className={styles.name} title={name} gap>
+ <Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
+ <Text truncate>{name}</Text>
+ </Row>
+ <div className={styles.count} title={nodeCount}>
+ <TooltipTrigger
+ delay={0}
+ isDisabled={columnIndex === 0 || (selectedNode && !selected)}
+ >
+ <Focusable>
+ <div>{formatLongNumber(nodeCount)}</div>
+ </Focusable>
+ <Tooltip placement="top" offset={20} showArrow>
+ <Text transform="lowercase" color="ruby">
+ {`${dropped}% ${formatMessage(labels.dropoff)}`}
+ </Text>
+ <Column>
+ <Text transform="lowercase">
+ {`${remaining}% ${formatMessage(labels.conversion)}`}
+ </Text>
+ </Column>
+ </Tooltip>
+ </TooltipTrigger>
+ </div>
+ {columnIndex < columns.length &&
+ lines.map(([fromIndex, nodeIndex], i) => {
+ const height =
+ (Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
+ NODE_GAP;
+ const midHeight =
+ (Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
+ NODE_GAP +
+ LINE_WIDTH;
+ const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
+
+ return (
+ <div
+ key={`${fromIndex}${nodeIndex}${i}`}
+ className={classNames(styles.line, {
+ [styles.active]:
+ active &&
+ activeNode?.paths.find(
+ (path: { items: any[] }) =>
+ path.items[columnIndex] === name &&
+ path.items[columnIndex - 1] === nodeName,
+ ),
+ [styles.up]: fromIndex < nodeIndex,
+ [styles.down]: fromIndex > nodeIndex,
+ [styles.flat]: fromIndex === nodeIndex,
+ })}
+ style={{ height }}
+ >
+ <div className={classNames(styles.segment, styles.start)} />
+ <div
+ className={classNames(styles.segment, styles.mid)}
+ style={{
+ height: midHeight,
+ }}
+ />
+ <div className={classNames(styles.segment, styles.end)} />
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+ },
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
new file mode 100644
index 0000000..14b8341
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
@@ -0,0 +1,67 @@
+'use client';
+import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { Journey } from './Journey';
+
+const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
+const DEFAULT_STEP = 3;
+
+export function JourneysPage({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+ const [steps, setSteps] = useState(DEFAULT_STEP);
+ const [startStep, setStartStep] = useState('');
+ const [endStep, setEndStep] = useState('');
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Grid columns="repeat(3, 1fr)" gap>
+ <Select
+ items={JOURNEY_STEPS}
+ label={formatMessage(labels.steps)}
+ value={steps}
+ defaultValue={steps}
+ onChange={setSteps}
+ >
+ {JOURNEY_STEPS.map(step => (
+ <ListItem key={step} id={step}>
+ {step}
+ </ListItem>
+ ))}
+ </Select>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.startStep)}
+ value={startStep}
+ onSearch={setStartStep}
+ delay={1000}
+ />
+ </Column>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.endStep)}
+ value={endStep}
+ onSearch={setEndStep}
+ delay={1000}
+ />
+ </Column>
+ </Grid>
+ <Panel height="900px" allowFullscreen>
+ <Journey
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ steps={steps}
+ startStep={startStep}
+ endStep={endStep}
+ />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx
new file mode 100644
index 0000000..f6062a6
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { JourneysPage } from './JourneysPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <JourneysPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Journeys',
+};